/*
 *  Copyright 2008 Blandware (http://www.blandware.com)
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */
package com.blandware.atleap.webapp.util.core;

import com.blandware.atleap.common.Constants;
import com.blandware.atleap.common.util.ConvertUtil;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import javax.servlet.ServletContext;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.lang.reflect.InvocationTargetException;

/**
 * <p>Cache for external resources. Allows to fetch resource through HTTP/HTTPS
 * caching it. The logic is following: first resource is checked to be in
 * cache. If not there, resource is loaded synchronously and returns result.
 * On this step resource may be put to cache, if requested. If resource is found
 * in cache, its age is checked against specified threshold; if it's expired,
 * existing version is returned, but asyncronous refresh thread is started; if
 * not expired, resource is just returned.</p>
 * <p>After refresh thread has finished refresh, value is changed; if it fails,
 * old value remains in cache.</p>
 * <p><a href="ExternalResourceCache.java.html"><i>View source</i></a></p>
 *
 * @author Roman Puchkovskiy <a href="mailto:roman.puchkovskiy@blandware.com">
 *         &lt;roman.puchkovskiy@blandware.com&gt;</a>
 * @version $Revision: 1.1 $ $Date: 2008/01/12 20:29:28 $
 */
public class ExternalResourceCache {

	/**
	 * Servlet context key under which instance of this class is saved
	 */
	private static final String INSTANCE_KEY = "com.blandware.atleap.webapp.util.core.ExternalResourceCache.INSTANCE";

	/**
	 * Commons Logging instance.
	 */
	protected transient final Log log
            = LogFactory.getLog(ExternalResourceCache.class);

    /**
     * Cache of resources
     */
    protected Map cache = new HashMap();

	/**
	 * Creates new instance of ExternalResourceCache.
	 */
	private ExternalResourceCache() {
	}

	/**
	 * Returns instance of ExternalResourceCache corresponding to a given
     * servlet context.
	 *
     * @param servletContext Servlet context
	 * @return Instance of ExternalResourceCache
	 */
	public static ExternalResourceCache getInstance(ServletContext servletContext) {
		ExternalResourceCache externalResourceCache = (ExternalResourceCache) servletContext.getAttribute(INSTANCE_KEY);
		if ( externalResourceCache == null ) {
			externalResourceCache = new ExternalResourceCache();
			servletContext.setAttribute(INSTANCE_KEY, externalResourceCache);
		}
		return externalResourceCache;
	}

    /**
     * <p>Loads external resource, possibly caching it. Content is not
     * recoded.</p>
     * <p>For this method logic description, see this class javadoc.</p>
     *
     * @param requestedUrl      URL of resource
     * @param cacheTime         time to cache resource (in seconds)
     * @param useCache          whether cache should be used; if false, resource
     * is loaded synchronously and is not put to cache
     * @return ResourceData instance describing fetched resource
     * @throws IOException  if error with i/o occures
     * @see com.blandware.atleap.webapp.util.core.ExternalResourceCache
     */
    public ResourceData loadExternalResource(String requestedUrl,
                                             int cacheTime,
                                             boolean useCache) throws IOException {
        return loadExternalResource(requestedUrl, cacheTime,
                Constants.DEFAULT_ENCODING, Constants.DEFAULT_ENCODING,
                useCache);
    }

    /**
     * <p>Loads external resource, possibly caching it.</p>
     * <p>For this method logic description, see this class javadoc.</p>
     * <p>If supplied encodings are distinct, resource is recoded.</p>
     *
     * @param requestedUrl      URL of resource
     * @param cacheTime         time to cache resource (in seconds)
     * @param fromEncoding      'from' encoding (native encoding of resource)
     * @param toEncoding        'to' encoding (encoding in which to output it)
     * @param useCache          whether cache should be used; if false, resource
     * is loaded synchronously and is not put to cache
     * @return ResourceData instance describing fetched resource
     * @throws IOException  if error with i/o occures
     * @see com.blandware.atleap.webapp.util.core.ExternalResourceCache
     */
    public ResourceData loadExternalResource(String requestedUrl,
                                             int cacheTime,
                                             String fromEncoding,
                                             String toEncoding,
                                             boolean useCache)
            throws IOException {
        ResourceDataInternal internalResult;
        if (useCache) {
            // using cache, trying to return cached value and, possibly,
            // refresh it asynchronously
            ResourceDataInternal resourceData = lookupCache(requestedUrl);
            if (resourceData != null) {
                // some cache exists: returning it and refreshing
                // asynchronously
                internalResult = resourceData;

                long currentTime = new Date().getTime();
                if ((currentTime - resourceData.getLastUpdated()) / 1000
                        > cacheTime) {
                    synchronized (resourceData) {
                        refreshContentAsynchronously(resourceData,
                                fromEncoding, toEncoding);
                    }
                }
            } else {
                // no cache - new or stall: refreshing synchronously and
                // returning
                internalResult = loadSynchronously(requestedUrl,
                        fromEncoding, toEncoding, true);
            }
        } else {
            // don't use cache, returning value synchronously
            internalResult = loadSynchronously(requestedUrl,
                    fromEncoding, toEncoding, false);
        }

        // copying data to safe struct to return it
        ResourceData result = new ResourceData();
        try {
            WebappUtil.copyProperties(result, internalResult);
        } catch (IllegalAccessException e) {
            if (log.isErrorEnabled()) {
                log.error(e);
            }
        } catch (InvocationTargetException e) {
            if (log.isErrorEnabled()) {
                log.error(e);
            }
        }
        return result;
    }

    /**
     * Returns data from cache by URL.
     *
     * @param url   URL which is used as a key
     * @return      data or null if not found
     */
    protected ResourceDataInternal lookupCache(String url) {
        return (ResourceDataInternal) cache.get(url);
    }

    /**
     * Puts resource data into cache using its URL as a key.
     *
     * @param url           URL of resource
     * @param resourceData  data to be put into cache
     */
    protected void putToCache(String url, ResourceDataInternal resourceData) {
        cache.put(url, resourceData);
    }

    /**
     * Loads resource synchronously and returns data. Also may recode resource
     * if encodings are distinct.
     *
     * @param requestedUrl  URL from which to take resource
     * @param fromEncoding  'from' encoding (native encoding of resource)
     * @param toEncoding    'to' encoding (encoding in which to output it)
     * @param useCache      if true, result will be put to cache
     * @return ResourceData instance containing needed data
     * @throws java.io.IOException if some problem with i/o occures
     */
    protected ResourceDataInternal loadSynchronously(String requestedUrl,
                                                     String fromEncoding,
                                                     String toEncoding,
                                                     boolean useCache)
            throws IOException {
        ResourceDataInternal result = new ResourceDataInternal();
        result.setUrl(requestedUrl);
        URL url = new URL(requestedUrl);
        URLConnection connection = url.openConnection();
        result.setContentType(connection.getContentType());
        InputStream is = url.openStream();
        result.setContent(IOUtils.toByteArray(is));
        is.close();
        result.setContent(convertContentEncodingIfNeeded(result.getContent(),
                fromEncoding, toEncoding));
        computeDigest(result);
        if (useCache) {
            putToCache(result.getUrl(), result);
        }
        return result;
    }

    /**
     * Refreshes cached content asynchronously.
     *
     * @param resourceData  resource data to refresh
     * @param fromEncoding  'from' encoding (native encoding of resource)
     * @param toEncoding    'to' encoding (encoding in which to output it)
     */
    protected void refreshContentAsynchronously(ResourceDataInternal resourceData,
                                                String fromEncoding,
                                                String toEncoding) {
        if (resourceData.getRefresher() == null) {
            resourceData.setRefresher(new Refresher(resourceData,
                    fromEncoding, toEncoding));
            resourceData.getRefresher().start();
        }
    }

    /**
     * Converts content from input encoding to output encoding, if they are not
     * the same.
     *
     * @param bytes             bytes that represent content to convert
     * @param fromEncoding      encoding from which to convert
     * @param toEncoding        encoding to which to convert
     * @return converted content
     */
    protected byte[] convertContentEncodingIfNeeded(byte[] bytes,
                                                    String fromEncoding,
                                                    String toEncoding) {
        byte[] result;
        if (!fromEncoding.equalsIgnoreCase(toEncoding)) {
            String str = ConvertUtil.convertToString(bytes, fromEncoding);
            result = ConvertUtil.convertToByteArray(str, toEncoding);
        } else {
            result = bytes;
        }
        return result;
    }

    /**
     * Computes content digest.
     *
     * @param resourceData  resorce data from which content is taken and to
     * which computed digest is put
     */
    protected void computeDigest(ResourceDataInternal resourceData) {
        StringBuffer buf;
        String digest;
        try {
            MessageDigest md = MessageDigest.getInstance("md5");
            md.reset();
            md.update(resourceData.getContent());
            byte[] digestBytes = md.digest();
            buf = new StringBuffer();
            for ( int i = 0; i < digestBytes.length; i++ ) {
                if ( (digestBytes[i] & 0xff) < 0x10 ) {
                    buf.append("0");
                }
                buf.append(Long.toString(digestBytes[i] & 0xff, 16));
            }
            digest = buf.toString();
        } catch (NoSuchAlgorithmException e) {
            digest = "";
        }
        resourceData.setDigest(digest);
    }

    /**
     * Thread that refreshes content. If exception occures, old content remains.
     */
    protected class Refresher extends Thread {

        /**
         * Resource data with which refresher works
         */
        private ResourceDataInternal resourceData;
        /**
         * Encoding from which to convert
         */
        private String fromEncoding;
        /**
         * Encoding to which to convert
         */
        private String toEncoding;

        /**
         * Constructor which takes resource data as argument.
         *
         * @param resourceData  data to work with
         * @param fromEncoding  native resource encoding
         * @param toEncoding    encoding to which to convert resource
         */
        public Refresher(ResourceDataInternal resourceData, String fromEncoding,
                         String toEncoding) {
            this.resourceData = resourceData;
            this.fromEncoding = fromEncoding;
            this.toEncoding = toEncoding;
        }

        /**
         * Performs refresher job.
         */
        public void run() {
            try {
                URL url = new URL(resourceData.getUrl());
                URLConnection connection = url.openConnection();
                resourceData.setContentType(connection.getContentType());
                InputStream is = url.openStream();
                byte[] newContent = IOUtils.toByteArray(is);
                is.close();
                resourceData.setContent(convertContentEncodingIfNeeded(newContent,
                        fromEncoding, toEncoding));
                computeDigest(resourceData);
                resourceData.setLastUpdated(new Date().getTime());
            } catch (IOException e) {
                if (log.isErrorEnabled()) {
                    log.error("Cannot refresh resource, URL is "
                              + resourceData.getUrl(), e);
                }
            } finally {
                resourceData.setRefresher(null);
            }
        }
    }

    /**
     * Struct that contains cached resource data.
     */
    public class ResourceData {
        /**
         * URL of content
         */
        private String url;
        /**
         * Cached content itself (in resulting encoding)
         */
        private byte[] content;
        /**
         * String representation of a digest
         */
        private String digest;
        /**
         * Content-type of resource
         */
        private String contentType;

        /**
         * Default constructor
         */
        public ResourceData() {
        }

        /**
         * Returns URL.
         *
         * @return URL
         */
        public String getUrl() {
            return url;
        }

        /**
         * Sets URL.
         *
         * @param url URL to set
         */
        public void setUrl(String url) {
            this.url = url;
        }

        /**
         * Returns content.
         *
         * @return content
         */
        public byte[] getContent() {
            return content;
        }

        /**
         * Sets content.
         *
         * @param content content to set
         */
        public void setContent(byte[] content) {
            this.content = content;
        }

        /**
         * Returns digest.
         *
         * @return digest
         */
        public String getDigest() {
            return digest;
        }

        /**
         * Sets digest.
         *
         * @param digest value to set
         */
        public void setDigest(String digest) {
            this.digest = digest;
        }

        /**
         * Returns content-type.
         *
         * @return content-type
         */
        public String getContentType() {
            return contentType;
        }

        /**
         * Sets content-type.
         *
         * @param contentType value to set
         */
        public void setContentType(String contentType) {
            this.contentType = contentType;
        }
    }

    /**
     * Internal struct that contains cached resource data (has fields for
     * refresher).
     */
    protected class ResourceDataInternal extends ResourceData {
        /**
         * Timestamp of last update
         */
        private long lastUpdated;
        /**
         * Thread which refreshes content
         */
        private Thread refresher;

        /**
         * Default constructor
         */
        public ResourceDataInternal() {
            super();
            lastUpdated = -1;
            refresher = null;
        }

        /**
         * Returns last updated time.
         *
         * @return last updated time
         */
        public long getLastUpdated() {
            return lastUpdated;
        }

        /**
         * Sets last updated time.
         *
         * @param lastUpdated time to set
         */
        public void setLastUpdated(long lastUpdated) {
            this.lastUpdated = lastUpdated;
        }

        /**
         * Returns refresher thread.
         *
         * @return refresher
         */
        public Thread getRefresher() {
            return refresher;
        }

        /**
         * Sets refresher thread.
         *
         * @param refresher thread to set
         */
        public void setRefresher(Thread refresher) {
            this.refresher = refresher;
        }
    }
}
